Mutation Bugs
Video Summary
In this video, we troubleshoot a bug in a “gradient generator” React application. The call to setColors
doesn't appear to work.
- When our React state uses an object or an array, we can't modify that array. This leads to bugs
- This is because React uses referential equality to tell if the state has changed or not. Mutating an array doesn't produce a new array, and so React won't re-render.
- We can fix this bug by creating a brand-new array instead, using “spread” syntax (
...
).
References:
Initial code from video:
Code Playground
Never mutate React state (even when it seems to work)
In the solution above, the order of operations was:
- Create a new array
- Modify that new array
- Set the new array into state
You might be wondering: is it OK to flip the order of the first two steps? What if we modify the array, then clone it? Like this:
<input onChange={event => { // Mutate the array: colors[index] = event.target.value;
// Create a new copy, and set it into state: setColors([...colors]); }}/>
Seems a bit simpler, right? Make whatever modifications we want, and then copy the array right before we call setColors
, so that we're providing a new value. And if you try this in the above sandbox, it appears to work?
Here's the problem: By doing it this way, we're modifying the values held in React state. React really doesn't expect us to do this, and there are no guardrails in place to warn us if we try.
You might get away doing this once, but if you make a habit of it, you'll likely start encountering weird / glitchy behaviour. Maybe a random part of the page won't update when it's supposed to. Or maybe the DOM structure will get shuffled, teleporting an element so that it sits in a totally different DOM container.
These bugs are really hard to diagnose and fix. You'll save yourself a whole lot of trouble if you do your best to avoid mutating React state.
But isn't this inefficient?
Some students have asked: isn't it inefficient to be creating brand-new arrays on every single change? Wouldn't it be much more performant to mutate the existing array instead?
When it comes to React state, there is no choice; we need to produce a new array whenever the state changes.
Fortunately, though, this isn't an issue. Cloning an array is an incredibly quick operation.
I decided to time it, to see how long it actually takes:
<input id={colorId} type="color" value={color} onChange={event => { const start = Date.now();
const nextColors = [...colors]; nextColors[index] = event.target.value;
console.log(Date.now() - start);
setColors(nextColors); }}/>
Date.now()
produces a large number like 1676568321545
; it's the number of milliseconds since January 1, 1970. It's a numerical representation of the current time.
We store a reference to the time before doing the expensive work. And afterwards, we check again, and take the difference. This gives us the number of milliseconds that have elapsed between the two timestamps, telling us how long the work took.
And what are the results? When I test this code on my M1 Max Macbook Pro, it claims it takes 0 milliseconds to do this work.
Of course, it can't literally be instantaneous; Date.now()
isn't precise below the millisecond scale. But it rounds to 0ms.
Hang on, though: my expensive Apple laptop isn't representative! How does it perform on lower-end hardware?
I repeated the test on my Acer NX TravelMate laptop, an Intel Celeron device which costs less than US$150. And the results are the same. It still takes 0ms.
Let's make it worse. What if we do the same work a thousand times?
<input id={colorId} type="color" value={color} onChange={event => { const start = Date.now();
let nextColors; for (let i = 0; i < 1000; i++) { nextColors = [...colors]; nextColors[index] = event.target.value; }
console.log(Date.now() - start);
setColors(nextColors); }}/>
On my slow-as-heck $150 Windows laptop, this takes about 2ms (it ranges between 0ms and 5ms, with 0ms being the median time).
When we do the math, that means each [...colors]
takes about 2 microseconds. That's two millionths of a second. For context, an average human blink is 100 milliseconds; we could clone 50,000 arrays in the blink of an eye.
To be fair, it does depend on the size of the array. If we had a million items in the array, that might be a different story. But we generally don't work with enormous data sets on the front-end. It would take too long to transfer that much data over the network, and low-end devices wouldn't have the memory to hold it all. Iteration speed is rarely the limiting factor on the front-end.
It's a good idea to test our intuition with this stuff. If you ever find yourself wondering whether a chunk of code is slow or not, give it a quick test and see for yourself!
I've done these sorts of tests a lot, and I've really come to appreciate how ⚡️ blazing fast ⚡️ modern JS engines are, even on low-end devices. When it comes to front-end performance, we have bigger things to worry about than this stuff. We'll talk more about performance later in this course.